Skip to content

feat: add DataProvider and OperationProvider plugin interfaces#355

Open
bburda wants to merge 11 commits intomainfrom
feature/plugin-entity-framework
Open

feat: add DataProvider and OperationProvider plugin interfaces#355
bburda wants to merge 11 commits intomainfrom
feature/plugin-entity-framework

Conversation

@bburda
Copy link
Copy Markdown
Collaborator

@bburda bburda commented Apr 6, 2026

Pull Request

Summary

Add per-entity provider routing for gateway plugins, enabling plugins to serve standard SOVD data and operation endpoints on entities they create via IntrospectionProvider.

  • New DataProvider and OperationProvider interfaces (per-entity routing, unlike singleton Log/Script/Update providers)
  • PluginLoader discovers providers via dlsym (get_data_provider, get_operation_provider)
  • PluginManager tracks entity ownership (entity_id -> plugin) for handler delegation
  • IntrospectionProvider now works in all discovery modes (not just hybrid)
  • PluginLayer tags entities with source = "plugin"
  • Data/operation handlers delegate to plugin providers for owned entities
  • Capability auto-deduction from registered providers

Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

  • 7 new unit tests for entity ownership tracking and provider resolution
  • Full gateway test suite: 1932 tests, 0 failures, 0 skipped

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

@bburda bburda self-assigned this Apr 6, 2026
bburda added 9 commits April 6, 2026 12:06
New provider interfaces for plugins that serve standard SOVD data
and operation resources on plugin-created entities. Per-entity routing
(unlike singleton Log/Script/Update providers) allows multiple plugins
to each handle their own entity sets.
PluginLoader queries get_data_provider() and get_operation_provider()
C exports from plugin .so files, following the same pattern as
existing get_log_provider/get_script_provider queries.
Entity ownership map (entity_id -> plugin_name) enables per-entity
routing to DataProvider/OperationProvider. Populated from
IntrospectionProvider results.
Plugin entities now appear in the entity cache regardless of discovery
mode (runtime_only, manifest_only, hybrid). In hybrid mode, plugins
participate in the merge pipeline as before. In other modes, entities
are injected during the regular cache refresh cycle.

All plugin entities get source='plugin'. Entity ownership is tracked
in PluginManager for per-entity provider routing.
EntityInfo now has is_plugin and plugin_name fields, populated during
entity lookup from PluginManager's ownership map. Enables handlers to
check entity ownership without querying PluginManager directly.
Data handlers (list, read, write) check EntityInfo.is_plugin and
delegate to the owning plugin's DataProvider instead of querying
ROS 2 topics via DataAccessManager.
Operation handlers (list, execute) check EntityInfo.is_plugin and
delegate to the owning plugin's OperationProvider instead of looking
up ROS 2 services/actions via OperationManager.
When a plugin entity has DataProvider or OperationProvider registered,
the entity detail response automatically includes 'data' and
'operations' in the capabilities array.
Tests verify entity ownership tracking in PluginManager: registration,
lookup, multi-plugin coexistence, provider resolution via add_plugin
(in-process dynamic_cast), and nullptr returns for unknown entities
and plugins without providers.
@bburda bburda force-pushed the feature/plugin-entity-framework branch from daf6a84 to 81db09c Compare April 6, 2026 10:11
… ops

The move constructor and move assignment operator were missing
data_provider and operation_provider fields, causing them to be
silently lost during plugin loading. This made
get_data_provider_for_entity() always return nullptr even though
dlsym successfully discovered the providers.
@bburda bburda requested a review from mfaferek93 April 6, 2026 15:00
New provider interface enabling plugins to serve SOVD fault endpoints
(GET /faults, GET /faults/{code}, DELETE /faults/{code}) on their
entities. Follows the same per-entity routing pattern as DataProvider
and OperationProvider.

Fault handlers delegate to FaultProvider for plugin-owned entities.
Capabilities auto-deduction includes 'faults' when FaultProvider is
registered.
@bburda bburda marked this pull request as ready for review April 6, 2026 16:10
Copilot AI review requested due to automatic review settings April 6, 2026 16:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the gateway plugin framework to support per-entity delegation of standard SOVD resource collections by introducing new provider interfaces (Data/Operation, plus Fault in this diff) and routing requests to the owning plugin for plugin-created entities.

Changes:

  • Add DataProvider, OperationProvider, and FaultProvider interfaces and load them via dlsym in PluginLoader.
  • Track entity ownership in PluginManager and tag entities for handler routing (EntityInfo::is_plugin / plugin_name).
  • Delegate /data, /operations, and /faults handler logic to plugin providers for plugin-owned entities; auto-add capabilities when providers are present.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/data_provider.hpp New per-entity DataProvider plugin API.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/operation_provider.hpp New per-entity OperationProvider plugin API.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/providers/fault_provider.hpp New per-entity FaultProvider plugin API.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_loader.hpp Extend load result to include new provider pointers and export symbols.
src/ros2_medkit_gateway/src/plugins/plugin_loader.cpp Discover new providers via dlsym(get_*_provider) and store pointers.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_manager.hpp Add entity ownership map + provider resolution APIs.
src/ros2_medkit_gateway/src/plugins/plugin_manager.cpp Implement ownership registration + per-entity provider lookup.
src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp Add plugin routing fields to EntityInfo.
src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp Populate EntityInfo::is_plugin/plugin_name from PluginManager ownership.
src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp Delegate /data endpoints to plugin DataProvider for plugin-owned entities.
src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp Delegate /operations endpoints to plugin OperationProvider for plugin-owned entities.
src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp Delegate /faults endpoints to plugin FaultProvider for plugin-owned entities.
src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp Auto-add data/operations/faults capabilities when providers resolve for an entity.
src/ros2_medkit_gateway/src/discovery/layers/plugin_layer.cpp Tag plugin-created entities with source="plugin" in hybrid pipeline.
src/ros2_medkit_gateway/src/gateway_node.cpp Register introspection providers in more modes; inject plugin entities in non-hybrid refresh.
src/ros2_medkit_gateway/test/test_plugin_entity_routing.cpp New unit tests for ownership tracking and provider resolution.
src/ros2_medkit_gateway/CMakeLists.txt Add new gtest target for plugin entity routing.
Comments suppressed due to low confidence (2)

src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp:395

  • For plugin-owned entities, POST /operations/{id} bypasses lock enforcement (validate_lock_access is only applied on the ROS2 path). Apply validate_lock_access() before calling OperationProvider::execute_operation() so plugin-backed operations honor the same locking semantics.
    // Delegate to plugin OperationProvider if entity is plugin-owned
    if (entity_info.is_plugin) {
      auto * pmgr = ctx_.node()->get_plugin_manager();
      auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr;
      if (op_prov) {
        json params = json::object();
        if (!req.body.empty()) {
          params = json::parse(req.body, nullptr, false);
          if (params.is_discarded()) {
            HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body");
            return;
          }
        }
        auto result = op_prov->execute_operation(entity_id, operation_id, params);
        if (result) {
          HandlerContext::send_json(res, *result);
        } else {
          HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
        }
        return;
      }
      HandlerContext::send_error(res, 404, ERR_OPERATION_NOT_FOUND,
                                 "No operation provider for plugin entity '" + entity_id + "'");
      return;
    }

    // Check lock access for operations
    if (ctx_.validate_lock_access(req, res, entity_info, "operations")) {
      return;
    }

src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp:676

  • For plugin-owned entities, DELETE /faults/{code} bypasses lock enforcement (validate_lock_access is only applied on the ROS2 path). Apply validate_lock_access() before calling FaultProvider::clear_fault() so plugin-backed fault clearing honors the same locking semantics.
    // Delegate to plugin FaultProvider if entity is plugin-owned
    if (entity_info.is_plugin) {
      auto * pmgr = ctx_.node()->get_plugin_manager();
      auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr;
      if (fault_prov) {
        auto result = fault_prov->clear_fault(entity_id, fault_code);
        if (result) {
          HandlerContext::send_json(res, *result);
        } else {
          HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
        }
        return;
      }
      HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND,
                                 "No fault provider for plugin entity '" + entity_id + "'");
      return;
    }

    // Check lock access for faults
    if (ctx_.validate_lock_access(req, res, entity_info, "faults")) {
      return;
    }

if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin delegation path sends errors using the literal "x-plugin-error". This is not a valid SOVD standard error code and it also won’t be treated as a vendor error by HandlerContext::send_error() (only x-medkit-* triggers vendor_error + vendor_code). Please use a defined error code from error_codes.hpp (e.g., ERR_VENDOR_ERROR with a vendor_code like x-medkit-plugin-error, or introduce a dedicated ERR_X_MEDKIT_PLUGIN_ERROR constant) and include useful parameters (entity_id, plugin_name) where available.

Suggested change
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
HandlerContext::send_error(
res, result.error().http_status, ERR_VENDOR_ERROR, result.error().message,
json{{"vendor_code", "x-medkit-plugin-error"}, {"entity_id", entity_id}});

Copilot uses AI. Check for mistakes.
Comment on lines +248 to 276
// Delegate to plugin DataProvider if entity is plugin-owned
if (entity_opt->is_plugin) {
auto * pmgr = ctx_.node()->get_plugin_manager();
auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr;
if (data_prov) {
json value;
if (!req.body.empty()) {
value = json::parse(req.body, nullptr, false);
if (value.is_discarded()) {
HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body");
return;
}
}
auto result = data_prov->write_data(entity_id, topic_name, value);
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
}
return;
}
HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND,
"No data provider for plugin entity '" + entity_id + "'");
return;
}

// Check lock access for data
if (ctx_.validate_lock_access(req, res, *entity_opt, "data")) {
return;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For plugin-owned entities, PUT /data bypasses lock enforcement (validate_lock_access is only applied on the ROS2 path). This allows modifying a locked collection without holding a lock when a plugin provider is used. Apply the same validate_lock_access() check before calling DataProvider::write_data().

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +64
// Delegate to plugin DataProvider if entity is plugin-owned
if (entity_info.is_plugin) {
auto * pmgr = ctx_.node()->get_plugin_manager();
auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr;
if (data_prov) {
auto result = data_prov->list_data(entity_id);
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
}
return;
}
HandlerContext::send_json(res, json{{"items", json::array()}});
return;
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New plugin delegation behavior in /data handlers isn’t covered by the existing DataHandlers gtests. Add unit tests (in test_data_handlers.cpp) that exercise plugin-owned entities for list/read/write, including error mapping and lock enforcement behavior.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +49 to +63
// Delegate to plugin OperationProvider if entity is plugin-owned
if (entity_info.is_plugin) {
auto * pmgr = ctx_.node()->get_plugin_manager();
auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr;
if (op_prov) {
auto result = op_prov->list_operations(entity_id);
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
}
return;
}
HandlerContext::send_json(res, json{{"items", json::array()}});
return;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin delegation path sends errors using the literal "x-plugin-error" which is not a valid SOVD error_code and won’t be treated as a vendor error. Use a constant from error_codes.hpp and a vendor code with the x-medkit-* prefix (or add a dedicated ERR_X_MEDKIT_PLUGIN_ERROR constant) so responses follow the GenericError schema.

Copilot uses AI. Check for mistakes.
Comment on lines 347 to +390
void OperationHandlers::handle_create_execution(const httplib::Request & req, httplib::Response & res) {
std::string entity_id;
std::string operation_id;
try {
if (req.matches.size() < 3) {
HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid request");
return;
}

entity_id = req.matches[1];
operation_id = req.matches[2];

// Validate entity ID and type for this route
auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id);
if (!entity_opt) {
return; // Response already sent (error or forwarded to peer)
}
auto entity_info = *entity_opt;

// Delegate to plugin OperationProvider if entity is plugin-owned
if (entity_info.is_plugin) {
auto * pmgr = ctx_.node()->get_plugin_manager();
auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr;
if (op_prov) {
json params = json::object();
if (!req.body.empty()) {
params = json::parse(req.body, nullptr, false);
if (params.is_discarded()) {
HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body");
return;
}
}
auto result = op_prov->execute_operation(entity_id, operation_id, params);
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
}
return;
}
HandlerContext::send_error(res, 404, ERR_OPERATION_NOT_FOUND,
"No operation provider for plugin entity '" + entity_id + "'");
return;
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New plugin delegation behavior in /operations handlers isn’t covered by the existing OperationHandlers tests. Add unit tests that cover plugin-owned entities for list and execute, including JSON body parsing failures, error mapping, and lock enforcement.

Copilot generated this review using guidance from repository custom instructions.
Comment on lines +647 to +665
// Register entity ownership for per-entity provider routing
for (auto & [name, provider] : introspection_providers) {
IntrospectionInput input;
auto result = provider->introspect(input);
std::vector<std::string> entity_ids;
for (const auto & area : result.new_entities.areas) {
entity_ids.push_back(area.id);
}
for (const auto & comp : result.new_entities.components) {
entity_ids.push_back(comp.id);
}
for (const auto & app : result.new_entities.apps) {
entity_ids.push_back(app.id);
}
for (const auto & func : result.new_entities.functions) {
entity_ids.push_back(func.id);
}
plugin_mgr_->register_entity_ownership(name, entity_ids);
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Entity ownership is registered by calling IntrospectionProvider::introspect() with an empty IntrospectionInput during node construction. IntrospectionProvider is defined to depend on the current discovery snapshot; using an empty input can produce a different set of entities than the actual discovery cycle (especially in HYBRID mode), causing handlers to miss plugin routing. Ownership should be registered from the same introspection results used during discovery/refresh (with the real IntrospectionInput) rather than an extra empty-input call here.

Suggested change
// Register entity ownership for per-entity provider routing
for (auto & [name, provider] : introspection_providers) {
IntrospectionInput input;
auto result = provider->introspect(input);
std::vector<std::string> entity_ids;
for (const auto & area : result.new_entities.areas) {
entity_ids.push_back(area.id);
}
for (const auto & comp : result.new_entities.components) {
entity_ids.push_back(comp.id);
}
for (const auto & app : result.new_entities.apps) {
entity_ids.push_back(app.id);
}
for (const auto & func : result.new_entities.functions) {
entity_ids.push_back(func.id);
}
plugin_mgr_->register_entity_ownership(name, entity_ids);
}
// Entity ownership must be registered from the same introspection results used by the
// discovery refresh cycle. Do not call provider->introspect() here with a default/empty
// IntrospectionInput, as that can diverge from the actual discovery snapshot and lead to
// incorrect per-entity plugin routing.

Copilot uses AI. Check for mistakes.
Comment on lines +1561 to +1586
// Inject plugin entities for non-hybrid modes.
// In hybrid mode, plugin entities are merged via the pipeline (PluginLayer).
// In runtime_only/manifest_only modes, we append them directly.
if (discovery_mgr_->get_mode() != DiscoveryMode::HYBRID && plugin_mgr_ && plugin_mgr_->has_plugins()) {
auto providers = plugin_mgr_->get_named_introspection_providers();
for (auto & [name, provider] : providers) {
IntrospectionInput input;
auto result = provider->introspect(input);
for (auto & area : result.new_entities.areas) {
area.source = "plugin";
areas.push_back(std::move(area));
}
for (auto & comp : result.new_entities.components) {
comp.source = "plugin";
all_components.push_back(std::move(comp));
}
for (auto & app : result.new_entities.apps) {
app.source = "plugin";
apps.push_back(std::move(app));
}
for (auto & func : result.new_entities.functions) {
func.source = "plugin";
functions.push_back(std::move(func));
}
}
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In refresh_cache(), plugin entities are injected for non-hybrid modes but entity ownership routing is not updated here. This can leave PluginManager::entity_ownership_ stale (or missing new entities) and cause plugin entities to be treated as non-plugin in handlers. Consider building IntrospectionInput from the current discovered entities (areas/components/apps/functions), calling introspect(), injecting new_entities, and updating/replacing ownership mapping in the same loop (ideally clearing old ownership for that plugin each refresh).

Copilot uses AI. Check for mistakes.
Comment on lines +430 to +436
void PluginManager::register_entity_ownership(const std::string & plugin_name,
const std::vector<std::string> & entity_ids) {
std::unique_lock<std::shared_mutex> lock(plugins_mutex_);
for (const auto & eid : entity_ids) {
entity_ownership_[eid] = plugin_name;
}
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register_entity_ownership() only inserts/overwrites entries but never removes stale ownership mappings (e.g., entities that disappear on a later refresh, or IDs later reused by non-plugin entities). This can cause incorrect is_plugin tagging and handler delegation. Consider making ownership registration replace the previous set for the plugin (or rebuild/clear entity_ownership_ on each cache refresh) and clear mappings when a plugin is disabled/unloaded.

Copilot uses AI. Check for mistakes.
Comment on lines +98 to +99
ei.is_plugin = true;
ei.plugin_name = *owner;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HandlerContext marks entities as plugin-owned purely from PluginManager::get_entity_owner(), without checking that the owning plugin is currently loaded or that any provider exists for the requested collection. If entity_ownership_ becomes stale, this can force handlers down the plugin path and change responses unexpectedly. Consider verifying the plugin is still active (or that at least one provider resolves for the entity) before setting is_plugin=true.

Suggested change
ei.is_plugin = true;
ei.plugin_name = *owner;
// Only treat the entity as plugin-backed if the owning plugin is still
// active enough to resolve a provider. This avoids stale ownership
// metadata forcing handlers down the plugin path unexpectedly.
auto introspection_provider = pmgr->get_introspection_provider(*owner);
if (introspection_provider) {
ei.is_plugin = true;
ei.plugin_name = *owner;
}

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +58
/**
* @brief Provider interface for entity data resources
*
* Typed provider interface for plugins that serve SOVD data resources
* (GET /{entity_type}/{id}/data, GET /{entity_type}/{id}/data/{name}).
* Unlike LogProvider/ScriptProvider (singletons), multiple DataProvider
* plugins can coexist - each handles its own set of entities.
*
* Entity ownership is determined by IntrospectionProvider: entities
* created by a plugin's introspect() are routed to that plugin's
* DataProvider.
*
* @par Thread safety
* All methods may be called from multiple HTTP handler threads concurrently.
* Implementations must provide their own synchronization.
*
* @see GatewayPlugin for the base class all plugins must also implement
* @see OperationProvider for the operations counterpart
*/
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new provider interfaces are part of the public plugin API (including the required dlsym export names in plugin_loader). The PR currently doesn’t update any developer-facing docs describing how to implement/export DataProvider/OperationProvider (and expected JSON schemas / error mapping). Please add documentation in the appropriate docs section (e.g., docs/design or a plugin author guide) and reference it here.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (3)

src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp:277

  • For plugin-owned entities, PUT /data/... bypasses validate_lock_access() and calls the provider directly. This allows mutating requests without the lock/X-Client-Id enforcement used for non-plugin entities; the lock check should be applied before delegating to the plugin provider as well.
    // Delegate to plugin DataProvider if entity is plugin-owned
    if (entity_opt->is_plugin) {
      auto * pmgr = ctx_.node()->get_plugin_manager();
      auto * data_prov = pmgr ? pmgr->get_data_provider_for_entity(entity_id) : nullptr;
      if (data_prov) {
        json value;
        if (!req.body.empty()) {
          value = json::parse(req.body, nullptr, false);
          if (value.is_discarded()) {
            HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body");
            return;
          }
        }
        auto result = data_prov->write_data(entity_id, topic_name, value);
        if (result) {
          HandlerContext::send_json(res, *result);
        } else {
          HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
        }
        return;
      }
      HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND,
                                 "No data provider for plugin entity '" + entity_id + "'");
      return;
    }

    // Check lock access for data
    if (ctx_.validate_lock_access(req, res, *entity_opt, "data")) {
      return;
    }

src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp:395

  • For plugin-owned entities, operation execution is delegated before calling validate_lock_access(). This bypasses the lock manager and allows operations to run without required lock ownership; apply the same lock validation to plugin-owned entities before calling execute_operation().
    // Delegate to plugin OperationProvider if entity is plugin-owned
    if (entity_info.is_plugin) {
      auto * pmgr = ctx_.node()->get_plugin_manager();
      auto * op_prov = pmgr ? pmgr->get_operation_provider_for_entity(entity_id) : nullptr;
      if (op_prov) {
        json params = json::object();
        if (!req.body.empty()) {
          params = json::parse(req.body, nullptr, false);
          if (params.is_discarded()) {
            HandlerContext::send_error(res, 400, ERR_INVALID_REQUEST, "Invalid JSON body");
            return;
          }
        }
        auto result = op_prov->execute_operation(entity_id, operation_id, params);
        if (result) {
          HandlerContext::send_json(res, *result);
        } else {
          HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
        }
        return;
      }
      HandlerContext::send_error(res, 404, ERR_OPERATION_NOT_FOUND,
                                 "No operation provider for plugin entity '" + entity_id + "'");
      return;
    }

    // Check lock access for operations
    if (ctx_.validate_lock_access(req, res, entity_info, "operations")) {
      return;
    }

src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp:676

  • For plugin-owned entities, clear_fault delegates to the plugin provider before validate_lock_access(), bypassing the lock manager used for non-plugin entities. Apply the same lock validation to plugin-owned entities before clearing faults.
    // Delegate to plugin FaultProvider if entity is plugin-owned
    if (entity_info.is_plugin) {
      auto * pmgr = ctx_.node()->get_plugin_manager();
      auto * fault_prov = pmgr ? pmgr->get_fault_provider_for_entity(entity_id) : nullptr;
      if (fault_prov) {
        auto result = fault_prov->clear_fault(entity_id, fault_code);
        if (result) {
          HandlerContext::send_json(res, *result);
        } else {
          HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
        }
        return;
      }
      HandlerContext::send_error(res, 404, ERR_RESOURCE_NOT_FOUND,
                                 "No fault provider for plugin entity '" + entity_id + "'");
      return;
    }

    // Check lock access for faults
    if (ctx_.validate_lock_access(req, res, entity_info, "faults")) {
      return;
    }

Comment on lines +54 to +60
auto result = data_prov->list_data(entity_id);
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
}
return;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handler returns plugin provider failures using a string literal error code ("x-plugin-error"). HandlerContext::send_error() expects SOVD/medkit error-code constants (and vendor errors must use the x-medkit- prefix to populate vendor_code). Add a proper constant in error_codes.hpp (e.g., x-medkit-plugin-error) and use it here so clients get a schema-compliant error response.

Copilot uses AI. Check for mistakes.
if (result) {
HandlerContext::send_json(res, *result);
} else {
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as DataHandlers: plugin provider failures are returned using the literal error code "x-plugin-error". Use a defined error-code constant from error_codes.hpp (with x-medkit- prefix if vendor-specific) so send_error() produces a schema-compliant SOVD GenericError response.

Suggested change
HandlerContext::send_error(res, result.error().http_status, "x-plugin-error", result.error().message);
HandlerContext::send_error(
res, result.error().http_status, ERR_SERVICE_UNAVAILABLE, result.error().message);

Copilot uses AI. Check for mistakes.
Comment on lines +69 to +78
/// Non-owning pointer to DataProvider interface (null if not provided).
/// Unlike LogProvider/ScriptProvider, multiple plugins can each provide data for different entities.
DataProvider * data_provider = nullptr;

/// Non-owning pointer to OperationProvider interface (null if not provided).
OperationProvider * operation_provider = nullptr;

/// Non-owning pointer to FaultProvider interface (null if not provided).
FaultProvider * fault_provider = nullptr;

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a new FaultProvider interface and handler delegation (get_fault_provider_for_entity / get_fault_provider symbol), but the PR title/summary focus on DataProvider + OperationProvider. Either update the PR description/title to reflect FaultProvider being included, or split FaultProvider into a separate change to keep scope and review surface aligned.

Copilot uses AI. Check for mistakes.
Comment on lines 44 to +66
/// Append plugin-registered capabilities to a capabilities JSON array
void append_plugin_capabilities(json & capabilities, const std::string & entity_type_path,
const std::string & entity_id, SovdEntityType entity_type, const GatewayNode * node) {
auto * pmgr = node->get_plugin_manager();
if (!pmgr || !pmgr->get_context()) {
if (!pmgr) {
return;
}
auto * ctx = pmgr->get_context();

std::string href_prefix;
href_prefix.reserve(64);
href_prefix.append("/api/v1/").append(entity_type_path).append("/").append(entity_id).append("/");

// Auto-add standard capabilities based on registered providers
if (pmgr->get_data_provider_for_entity(entity_id) && !has_capability(capabilities, "data")) {
capabilities.push_back({{"name", "data"}, {"href", href_prefix + "data"}});
}
if (pmgr->get_operation_provider_for_entity(entity_id) && !has_capability(capabilities, "operations")) {
capabilities.push_back({{"name", "operations"}, {"href", href_prefix + "operations"}});
}
if (pmgr->get_fault_provider_for_entity(entity_id) && !has_capability(capabilities, "faults")) {
capabilities.push_back({{"name", "faults"}, {"href", href_prefix + "faults"}});
}

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capability auto-deduction now depends on per-entity plugin provider routing. Please update the REST API docs (docs/api/rest.rst) to describe how plugin-owned entities expose standard collections (/data, /operations, /faults) and how provider failures are surfaced to clients, since this changes discovery/capabilities behavior.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin entity framework: DataProvider and OperationProvider for standard SOVD endpoints

2 participants